Medications Overview
The Patient Portal Medications API lets the authenticated patient list the medications prescribed on their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me/medications | List the patient's prescribed medications (optionally filtered by case) |
Related resources: Orders (/me/orders) and Payments (/me/payments).
Authentication
Every endpoint requires a successful /verify-otp exchange first.
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes | Tenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR. |
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".
Permission Matrix
| Action | Allowed when… |
|---|---|
| List own medications | Always (filtered to the patient's cases in the calling org). |
| List a specific case's medications | The case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403. |
When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.
Response Envelope
The list is wrapped under medications alongside the pagination cursor:
{
"status": 200,
"success": true,
"data": {
"medications": [ "..." ],
"nextCursor": "<id> | null"
}
}
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Query Parameters
| Field | Type | Required | Notes |
|---|---|---|---|
caseId | string (UUID) | No | Restrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403. |
limit | integer | No | 1–100. Defaults to 20. Coerced from string. |
after | string (UUID) | No | Cursor — the last id from the previous page. The server skips that row and returns the next page. |
Pagination
Cursor-based over the row id, ordered by the parent decision's createdAt descending, then id ascending:
- Request page 1 without
after. The server returns up tolimititems plusnextCursor. - If
nextCursoris non-null, pass it asafter=<nextCursor>to fetch the next page. - When the server has no more rows,
nextCursorisnull.
The cursor is the last item's id (a CaseDecisionMedInfo.id). Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."
Object Shapes
Medication
Returned by GET /me/medications. One row per CaseDecisionMedInfo — a single decision with multiple meds yields multiple rows.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseDecisionMedInfo.id. Use this value in after for pagination. |
medicine | string | Drug name. |
dosage | string | null | e.g. "500 mg". |
refillCount | number | null | Number of refills authorized. |
dosingFrequency | string | null | e.g. "twice daily". |
treatmentPeriod | string | null | Free-text duration. |
isRefill | boolean | null | Whether this entry represents a refill. |
pharmacyInstructions | string | null | Free-text instructions for the pharmacy. |
prescribedDate | ISO-8601 datetime | null | CaseDecision.startDate. |
caseId | string (UUID) | The case this prescription belongs to. |
isApproved | boolean | Whether the underlying decision is approved. |
prescriber | { id, firstName, lastName, title } | null | The User who authored the decision (CaseDecision.addedBy). |
Server-Side Behaviors and Defaults
- Tenant + ownership scoping. With or without
caseId, the result is restricted to cases wheresubmitterId = userIdandorganizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface. caseIdis pre-validated. When supplied,ensurePatientOwnsCaseruns before the list query; failure short-circuits to403.- No row-level soft delete. Medication rows have no
isDeletedflag; everymedInforow on an in-scope decision is returned. - Default
limit.20. Maximum100. The validator coerces string → number. - Cursor semantics.
afteris the last row'sidfrom the previous page; the server usescursor: { id: after }, skip: 1and asks forlimit + 1rows to detect end-of-results. - Empty patient. If the patient has no cases in the calling org (and no
caseIdwas supplied), the endpoint returns{ medications: [], nextCursor: null }— no error.
Security Properties
- Tenant isolation. Case-id resolution pins
organizationIdto the calling org fromcv-api-key; the medication query is filtered to the case ids that resolution returns. - Ownership isolation. Case-id resolution pins
submitterIdto the JWT subject;caseIdqueries additionally pass throughensurePatientOwnsCase. - Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same
403 VALIDATION_ERROR"You do not have access to this case" so case ids cannot be probed. - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. The JWT's
organizationIdis verified against thecv-api-key-resolved org on every call. - No write surface. The endpoint is read-only.
Integrator Guidance
- Refresh proactively. Refresh the access token via
/refresh-tokenbefore the 15-minute expiry. - Listing strategy. Use
?caseId=when surfacing medications within a single-case view; omit it for an account-wide list. - Paginate forward only. The cursor moves forward through the sort order — there is no
beforecursor. - Prefer this endpoint for medication detail. The medications embedded in an
Order(order.medications[]) carry onlymedicine / dosage / dosingFrequency;prescriber,refillCount,isApproved, and the rest are only here. - Expect multiple rows per decision. One prescription decision with three drugs returns three rows sharing the same
caseIdandprescribedDate. - Treat
403as "no access, may or may not exist". Do not display case-id-specific debug text.